feat: add threshold filtering to knn search - #2531
Conversation
|
/label status/ready-to-merge |
|
Automated pull request review completed. Review effort: Submitted 2 inline comments. |
Merge Protections🟢 All 3 merge protections satisfied — ready to merge. Show 3 satisfied protections🟢 Require kind label
🟢 Require version label
🟢 Require linked issue for feature/bug PRs
|
vsag-bot
left a comment
There was a problem hiding this comment.
Automated inline review completed.
Review effort: medium (246 changed lines across 13 files).
Submitted 3 inline comments.
Reviewed commit 9b6cf96.
9b6cf96 to
e718e38
Compare
vsag-bot
left a comment
There was a problem hiding this comment.
Automated inline review completed.
Review effort: high (397 changed lines across 15 files).
Submitted 7 inline comments.
Reviewed commit e718e38.
e718e38 to
d05a524
Compare
LHT129
left a comment
There was a problem hiding this comment.
This update addresses all six issues raised in the previous review round:
- IVF reorder threshold — Threshold is now applied after exact-distance reorder, with
FilterDatasetByThresholdcapping attopk_. The candidate pool is retained at full size when threshold is present. - Non-finite threshold validation —
ValidateSearchThresholdrejects NaN/Inf inSearchWithRequestpaths, andindex_impl.hvalidates insideSAFE_CALLfor consistent error handling. - Allocator preservation —
FilterDatasetByThresholdaccepts anAllocator*, allocates through it, and setsOwner(true, allocator). HNSW and SINDI pass their caller allocator; a regression test verifies balanced deallocation. - route_buckets_only threshold — IVF now explicitly rejects threshold with
disable_bucket_scanviaCHECK_ARGUMENT. - IVF reasoning report —
AttachReasoningReportis called afterFilterDatasetByThresholdin the reorder path. - Pyramid/SIMQ threshold — Both now parse and apply threshold via
FilterDatasetByThresholdwith their allocators and k-capping.
Additional observations:
- The
#include <unordered_set>addition intest_brute_force.cppfixes a missing-include bug in the existing code (it was used but not included). ParseSearchThresholdnow validates JSON type viaIsNumber()before callingGetFloat(), preventing silent coercion of string values.- The
SetOFFFirstUsed()call in HGraph when threshold filtering empties the result is a correct fix to maintain iterator filter state consistency. FilterDatasetByThresholdcorrectly preserves statistics and reasoning from the input dataset.
One minor suggestion was posted inline about the two-pass iteration in FilterDatasetByThreshold.
vsag-bot
left a comment
There was a problem hiding this comment.
Automated inline review completed.
Review effort: high (1177 changed lines across 27 files).
Submitted 4 inline comments.
Reviewed commit b35bbc3.
b35bbc3 to
eb2d560
Compare
There was a problem hiding this comment.
🟡 Not ready to approve
The current top-level threshold validation in IndexImpl appears to change legacy (unsupported) index behavior and the new threshold utility contains dead/unreachable code that should be corrected.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (3)
src/utils/search_threshold.h:120
- The
else if (extra_infos == nullptr && extra_size > 0 && input_extra_infos != nullptr)branch is effectively unreachable: whenresult_count > 0,extra_size > 0, andinput_extra_infosis non-null,AllocateThresholdArray<char>(result_count * extra_size, ...)either returns a non-null pointer or throws, soextra_infoscannot remain null. This dead code makes the extra-info handling harder to reason about.
if (result_count == 0) {
result->ExtraInfos(nullptr)->ExtraInfoSize(0);
} else if (extra_infos == nullptr and extra_size > 0 and input_extra_infos != nullptr) {
result->ExtraInfos(nullptr)->ExtraInfoSize(extra_size);
}
src/index/index_impl.h:591
ValidateThresholdParameters()is invoked for allKnnSearchoverloads and always callsParseSearchThreshold(parameters). That means legacy/unsupported indexes (e.g. HNSW/DiskANN) will now start rejecting requests like{ "threshold": "bad" }even though they don’t implement threshold filtering (and their own parameter parsing would otherwise ignore the field). This contradicts the docs/PR statement that threshold support does not add or change behavior for those legacy indexes. Consider only validatingthresholdon the maintained indexes that actually support it.
ValidateThresholdParameters(const std::string& parameters) const {
try {
ParseSearchThreshold(parameters);
return {};
} catch (const VsagException& e) {
docs/docs/en/src/guide/knn_search.md:95
- Grammar: “are explicit unsupported non-goals” should be “are explicitly unsupported non-goals”.
Threshold filtering is supported by the maintained indexes: BruteForce, HGraph, IVF, Pyramid,
SINDI, and SIMQ. HNSW and DiskANN are deprecated and are explicit unsupported
non-goals; this option does not add or change behavior for either legacy index.
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
vsag-bot
left a comment
There was a problem hiding this comment.
Automated inline review completed.
Review effort: high (1276 changed lines across 27 files).
Submitted 6 inline comments.
Reviewed commit eb2d560.
LHT129
left a comment
There was a problem hiding this comment.
Thanks for this comprehensive PR. The threshold filtering feature is well-implemented across all six maintained indexes with thorough test coverage (109 assertions in 9 threshold-specific cases, plus full unit suite passing).
I reviewed the full diff and found only minor suggestions beyond what has already been addressed in the 30 existing review threads:
-
[suggestion] The
traversal_priorityandis_result_distance_eligiblehelpers are duplicated inbasic_searcher.cppandparallel_searcher.cpp. Consider extracting to a shared header. -
[note] An unreachable defensive branch in
FilterDatasetByThreshold(theelse ifforextra_infos == nullptr).
The core logic — non-finite distance filtering, threshold validation at the IndexImpl layer, IVF reorder/non-reorder paths, HGraph iterator page consumption, and the graph searcher traversal changes — all look correct. The GetNumElements() → GetDim() fix in AttachReasoningReport is a good catch.
No blocking issues found.
eb2d560 to
04228c0
Compare
Signed-off-by: Xiangyu Wang <wxy407827@antgroup.com> Assisted-by: OpenAI Codex:GPT-5
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.
Suppressed comments (7)
tests/test_simq.cpp:1
- This file uses
std::numeric_limitsandstd::isnan, but does not include<limits>/<cmath>directly in the shown includes. Relying on transitive includes (via other project headers) can make the build fragile across platforms/standard libraries; add the needed standard headers explicitly.
docs/docs/en/src/guide/knn_search.md:95 - Grammar: 'explicit unsupported' should be 'explicitly unsupported' to read correctly.
Threshold filtering is supported by the maintained indexes: BruteForce, HGraph, IVF, Pyramid,
SINDI, and SIMQ. HNSW and DiskANN are deprecated and are explicit unsupported
non-goals; this option does not add or change behavior for either legacy index.
src/index/index_impl.h:309
- IndexImpl now parses JSON in
ValidateThresholdParameters()purely to validatethreshold. For indexes that later callParseSearchThreshold(parameters)again (e.g., to populateSearchRequest::threshold_), this introduces redundant JSON parsing on every KNN call. Consider restructuring so the threshold is parsed once and reused (e.g., parse once in IndexImpl and store inSearchParam/SearchRequest, or validate using the already-parsedthreshold_path for the request-based APIs).
BitsetPtr invalid = nullptr) const override {
auto threshold_validation = ValidateThresholdParameters(parameters);
if (not threshold_validation.has_value()) {
return tl::unexpected(threshold_validation.error());
}
src/algorithm/simq/simq.cpp:793
- Because
rerankedis sorted by ascending distance (and NaNs are ordered last), whenthresholdis present you can break early once you encounter the first finitedistance > threshold—no later finite entries can pass the threshold. This avoids scanning potentially largererankedvectors in cases where the threshold excludes most results.
int64_t result_count = 0;
for (const auto& [distance, _] : reranked) {
if (result_count >= k) {
break;
}
if (not threshold.has_value() or
(std::isfinite(distance) and distance <= threshold.value())) {
++result_count;
}
}
tests/test_brute_force.cpp:57
allocation_count_/AllocationCount()reads like it might represent currently active allocations, but it actually tracks a monotonically increasing total number of successful allocations. Renaming to something liketotal_allocation_count_(or adding a short comment) would prevent misinterpretation in future tests.
if (ptr != nullptr) {
allocations_[ptr] = size;
allocated_bytes_ += size;
allocation_count_ += 1;
}
tests/test_brute_force.cpp:113
allocation_count_/AllocationCount()reads like it might represent currently active allocations, but it actually tracks a monotonically increasing total number of successful allocations. Renaming to something liketotal_allocation_count_(or adding a short comment) would prevent misinterpretation in future tests.
uint64_t
AllocationCount() const {
std::scoped_lock lock(mutex_);
return allocation_count_;
}
tests/test_brute_force.cpp:125
allocation_count_/AllocationCount()reads like it might represent currently active allocations, but it actually tracks a monotonically increasing total number of successful allocations. Renaming to something liketotal_allocation_count_(or adding a short comment) would prevent misinterpretation in future tests.
uint64_t allocation_count_{0};
LHT129
left a comment
There was a problem hiding this comment.
Code Review Summary
All 14 previous review comments have been addressed and resolved in commit 04228c00. The current code state looks correct across all reviewed dimensions:
Correctness
- Threshold filtering is correctly applied after reorder in IVF (exact distances), before reorder in the bucket search path (approximate distances with
enable_reorder=false), and viaFilterDatasetByThresholdin SINDI/Pyramid - Non-finite distance handling in
basic_searcherandparallel_searchercorrectly usestraversal_priority()for heap ordering andis_result_distance_eligible()for result inclusion FilterDatasetByThresholdproperly handles theresult_count == 0case (null ExtraInfos, zero ExtraInfoSize)AllocateThresholdArraycorrectly returnsnullptrfor zero-count and throwsstd::bad_allocon allocator failureValidateThresholdParametersis called before empty-index checks in all 5KnnSearchoverloads andSearchWithRequest- IVF
route_buckets_onlypath explicitly rejects threshold withCHECK_ARGUMENT - HGraph iterator path properly consumes threshold-rejected candidates via
SetPointand retries internally on empty pages hgraph_build.cppdefensively checksEmpty()beforeTop()on route graph search results
Test Coverage
- BruteForce: threshold filtering, non-finite distances, NaN base vectors, IP metric, allocator ownership, empty index validation, malformed JSON rejection
- HGraph: iterator threshold consumption, non-finite entry distances, non-finite candidate filtering, infinity preservation without threshold, build tolerance for non-finite route probes
- Searcher unit tests: non-finite distance bridge traversal for both basic and parallel searchers
FilterDatasetByThresholdunit tests: allocator ownership, extra-info preservation, non-finite input, allocation failure
No New Issues Found
The implementation is consistent across all 6 maintained indexes. No blocking issues, no suggestions worth flagging.
LHT129
left a comment
There was a problem hiding this comment.
Code Review Summary (commit 04228c0)
All 14 previous review comments have been addressed and resolved. After thorough review of all 30 changed files, no new issues were found. The implementation is consistent across all 6 maintained indexes (BruteForce, HGraph, IVF, Pyramid, SINDI, SIMQ).
Key areas verified:
- Threshold filtering is correctly applied after reorder in IVF, and before reorder in bucket search path
- Non-finite distance handling in basic_searcher/parallel_searcher correctly uses traversal_priority() and is_result_distance_eligible()
- FilterDatasetByThreshold properly handles result_count==0, allocator ownership, and extra-info preservation
- ValidateThresholdParameters is called before empty-index checks in all KnnSearch overloads
- IVF route_buckets_only explicitly rejects threshold
- HGraph iterator path consumes threshold-rejected candidates via SetPoint
- hgraph_build.cpp defensively checks Empty() before Top()
Test coverage includes BruteForce, HGraph, searcher unit tests, and FilterDatasetByThreshold unit tests covering threshold filtering, non-finite distances, NaN, IP metric, allocator ownership, and allocation failure.
LHT129
left a comment
There was a problem hiding this comment.
Code Review Summary (commit 04228c0)
All 14 previous review comments have been addressed and resolved. After thorough review of all 30 changed files, no new issues were found.
Key areas verified:
- Threshold filtering correctly applied after reorder in IVF, before reorder in bucket search path
- Non-finite distance handling in basic_searcher/parallel_searcher uses traversal_priority() and is_result_distance_eligible()
- FilterDatasetByThreshold handles result_count==0, allocator ownership, extra-info preservation
- ValidateThresholdParameters called before empty-index checks in all KnnSearch overloads
- IVF route_buckets_only explicitly rejects threshold
- HGraph iterator path consumes threshold-rejected candidates via SetPoint
- hgraph_build.cpp defensively checks Empty() before Top()
Test coverage: BruteForce, HGraph, searcher unit tests, FilterDatasetByThreshold unit tests.
04228c0 to
c9bceda
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.
Suppressed comments (4)
docs/docs/en/src/guide/knn_search.md:95
- Grammar issue: “are explicit unsupported” should be “are explicitly unsupported”. This reads as a typo in the public docs.
SINDI, and SIMQ. HNSW and DiskANN are deprecated and are explicit unsupported
non-goals; this option does not add or change behavior for either legacy index.
src/index/index_impl.h:310
- Threshold parameter validation now runs before
CHECK_QUERY_RETURN_EMPTY_DATASET(query). This changes observable behavior: previously an empty/invalid query could short-circuit to an empty dataset without parsing parameters, but now malformedthreshold(or invalid JSON) can fail the call even when the query is empty. Consider moving the threshold validation after the empty-query fast path (and ideally after the empty-index fast path as well) so empty queries preserve existing behavior and avoid unnecessary JSON parsing.
auto threshold_validation = ValidateThresholdParameters(parameters);
if (not threshold_validation.has_value()) {
return tl::unexpected(threshold_validation.error());
}
CHECK_QUERY_RETURN_EMPTY_DATASET(query);
src/index/index_impl.h:503
SAFE_CALL(...)now wraps multiple statements (including a conditional early return). This makes the control flow harder to read and increases the chance of subtle macro-related issues. A clearer structure would be to wrap only the throwing calls (e.g.,ValidateSearchThreshold(...)and/orinner_index_->SearchWithRequest(...)) in separateSAFE_CALLinvocations, keeping ordinary branching outside the macro.
SAFE_CALL(ValidateSearchThreshold(request.threshold_);
if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(request.params_str_)) {
return DatasetImpl::MakeEmptyDataset();
} return this->inner_index_->SearchWithRequest(request));
src/algorithm/simq/simq.cpp:793
- When
thresholdis not set, this counts (and later may return) results withdistance = NaN, because the eligibility check becomes unconditional. Other KNN paths in this PR explicitly treat NaN as ineligible/unordered. To keep SIMQ consistent and prevent returning NaN distances to callers, exclude NaNs regardless of whetherthresholdis present (e.g., requirenot std::isnan(distance)in both branches).
int64_t result_count = 0;
for (const auto& [distance, _] : reranked) {
if (result_count >= k) {
break;
}
if (not threshold.has_value() or
(std::isfinite(distance) and distance <= threshold.value())) {
++result_count;
}
}
vsag-bot
left a comment
There was a problem hiding this comment.
Automated inline review completed.
Review effort: high (1623 changed lines across 34 files).
Submitted 2 inline comments.
Reviewed commit c9bceda.
| bool enable_reorder) const { | ||
| if (threshold.has_value() and | ||
| (not std::isfinite(dist) or (not enable_reorder and 1.0F + dist > threshold.value()))) { | ||
| return false; |
There was a problem hiding this comment.
[P2] Clear rejected SINDI scores before reusing the window buffer
search_impl reuses one dists array across mutable windows and relies on heap insertion to zero each processed slot, but this new early return leaves the rejected partial distance intact. With window_size=10000, a rejected first-window score of -0.1 is added to the next window's true -0.4 score, causing a threshold-0.5 query to return ID 10000 at 0.5 even though its true distance is 0.6. Clear dist before every threshold-rejection return; insert_candidate_into_heap has the same stale-slot path.
| for (const auto& record : finite_records) { | ||
| search_result->Push(record); | ||
| } | ||
| filter_search_result_by_threshold(search_result, request.threshold_, ctx.alloc); |
There was a problem hiding this comment.
[P2] Reject non-finite exact distances before the reorder k cap
With precise reorder enabled, reorder(..., k) has already bounded the exact heap before this post-filter runs. In an SQ8-base/FP32-precise L2 index containing {NaN, 1, 0}, querying 1 with k=1, ef_search=3, and threshold 0 returned empty in 5 of 30 builds even though the distance-zero candidate was in the approximate pool: when the NaN exact distance enters FlattenReorder first, later finite comparisons against its heap top fail. Exclude non-finite exact distances inside bounded reorder selection, including iterator reorder, so eligible finite candidates can backfill.
LHT129
left a comment
There was a problem hiding this comment.
[note] SINDI threshold comparison uses 1.0F + dist which accounts for the internal dist = -ip representation. This is correct for IP metric but would be incorrect for L2. If SINDI only supports IP, consider documenting this assumption or adding a metric guard to prevent future misuse when L2 support is added. (src/algorithm/sindi/sindi.cpp:736, src/datacell/sparse_term_datacell.cpp:75,124)
LHT129
left a comment
There was a problem hiding this comment.
[note] The is_result_distance_eligible<KNN_SEARCH> function in searcher_utils.h rejects non-finite distances when a threshold is set, but only checks std::isfinite(distance). This means -inf distances (which are "better" than any finite distance) are also rejected. While -inf is unusual in practice, if it were to occur (e.g., from a degenerate vector), it would be incorrectly excluded from results. Consider using std::isnan instead of not std::isfinite for the threshold-aware check, or document that -inf is intentionally excluded. (src/impl/searcher/searcher_utils.h:30-31)
LHT129
left a comment
There was a problem hiding this comment.
[note] In flat_bucket_searcher.cpp, the KNN path checks param.distance_threshold but the RANGE_SEARCH path does not. This is intentional since threshold is a KNN-only feature, but the asymmetry could be confusing to future readers. Consider adding a brief comment in the RANGE_SEARCH path noting that threshold filtering is intentionally not applied here. (src/algorithm/ivf/flat_bucket_searcher.cpp:71-72,99+)
LHT129
left a comment
There was a problem hiding this comment.
Overall Review
This PR adds optional inclusive distance threshold filtering to KNN search across all 6 maintained indexes (BruteForce, HGraph, IVF, Pyramid, SINDI, SIMQ). The implementation is thorough and well-tested after multiple rounds of review.
What works well
-
Consistent API:
std::optional<float>threshold flows throughSearchRequest→InnerSearchParam→ individual index implementations. All indexes use the sameParseSearchThreshold/ValidateSearchThresholdutilities. -
NaN/inf safety: All threshold comparison paths use
std::isfinitechecks before comparing distances. Thefilter_search_result_by_thresholdhelper andis_result_distance_eligibletemplate both guard against unordered float comparisons. -
Exception safety:
FilterDatasetByThresholdattaches each allocation to the owning Dataset immediately, preventing leaks on allocation failure. TheAllocateThresholdArrayhelper provides a unified allocation path. -
Test coverage: Every maintained index has threshold-specific tests covering: basic filtering, NaN handling, non-finite rejection, empty results, IP metric, iterator paths (HGraph), reorder paths (IVF), and allocator ownership.
-
Review iteration quality: All issues from previous review rounds (iterator consumption, IVF reasoning report ordering, SIMQ statistics accuracy, empty-index JSON validation, NaN-safe heap filtering, allocator ownership) have been properly addressed.
Minor observations (non-blocking)
-
SINDI
1.0F + distpattern (sindi.cpp:736,sparse_term_datacell.cpp:75,124): Correctly accounts for the internaldist = -iprepresentation, but applies unconditionally regardless of metric type. If SINDI ever supports L2, this would silently produce wrong results. -
-infrejection (searcher_utils.h:30-31):is_result_distance_eligible<KNN_SEARCH>rejects all non-finite distances when threshold is set, including-infwhich is technically "better" than any finite distance. This is unlikely to occur in practice but worth noting. -
flat_bucket_searccher.cppasymmetry: The KNN path has threshold filtering but the RANGE_SEARCH path does not — intentional but could benefit from a brief comment.
Verdict
The implementation is correct, well-tested, and all previously identified issues have been resolved. Approved.
Summary
Add inclusive top-level distance threshold filtering for KNN in the maintained indexes only:
HNSW and DiskANN are deprecated and explicitly unsupported non-goals for this PR. Their source, test, and documentation paths are absent from the full PR diff.
Threshold filtering excludes non-finite values before bounded result selection and allows eligible finite candidates to backfill. Non-reorder IVF, HGraph, Pyramid, and SINDI apply eligibility before bounded selection; reordered paths apply the bound to precise distances. HGraph iterator search consumes rejected pages and retained discard state internally. Ordinary KNN and range search preserve eligible infinite results while non-finite graph nodes remain safe traversal seeds. Threshold scratch storage uses the active request allocator.
Validation
Fixes: #2081